前回までは、specの構造と並び順の話でした。今回はitの中身です。
「1つのitに1つのexpect」という書き方をよく見かけます。RSpecの入門記事にもだいたい書いてあります。ですが、今はまとめて書いた方がいいと考えています。

「1 expectation per it」が解こうとしていた問題

この書き方が広まったのは、失敗した時にどれが落ちたか分かるようにするためです。

1つのitに複数のexpectを書くと、最初のexpectで落ちた時点で以降が実行されません。3つ検証していて1つ目が落ちたら、2つ目と3つ目が通るかどうかは分からないままです。

分けておけば、それぞれ独立して結果が出ます。

これは正しい動機です。ただし、aggregate_failuresがなかった時代の話でもあります。

aggregate_failuresが提案されたのは2015年2月、リリースされたのは同年6月のRSpec 3.3です。提案者のMyron Marstonは、「itを分ければ失敗ごとに出力が得られるが、まとめた方が速く実行できる。特に対象の処理が重い場合はそうだ。どちらも理想的ではない」という問題意識を挙げています。

つまりitを分けたくない側のために作られた機能です。目的は今も残っていますが、手段が増えました。

分けると何が起きるか

原因1つに対して赤がN個出る

request specで、レスポンス・DBの変更・非同期処理の呼び出しを検証しているとします。分けるとこうなります。

it 'HTTPステータスが200' do
it 'レスポンスが一致する' do
it 'スペースが作成される' do
it 'ジョブが呼ばれる' do

ここで実装が壊れて200が返らなくなると、4つ全部落ちます。原因は1つなのに赤が4個。

CIの結果一覧では横に並ぶので、「何かたくさん壊れた」という第一印象になります。心理的な負担が違います。原因が1つだと分かるまで、4個分の重さを感じることになる。

修正する時も、1つずつ見て「これは同じ原因だな」と判断する作業が入ります。原因の数と赤の数が一致していれば、この作業が要りません。

まとめてあれば、落ちるのは1つです。

説明文が検証内容の写しになる

分けていくと、itの説明文がexpectの内容をそのまま日本語にしたものになります。

it 'ステータスがexpiredになる' do
  expect(model.status).to eq('expired')
end

この説明文は、実装が変わっても自動では変わりません。ステータスの名前が変わっても、説明文が古いまま緑で通ります。検証されないドキュメントが増えていきます。

これが「itを分けない」を原則にしている理由です。 分けるほど説明文が増え、そのすべてが実装とズレる可能性を持ちます。

まとめてit 'スペースが作成される'くらいの粒度にしておけば、細かい検証内容はコードを読ませることになるので、ズレようがありません。

検証項目が散らばる

前々回、レビューで判断することの1つに「1ケースの中で、検証すべき項目に漏れがないか」を挙げました。

分けると、この判断が難しくなります。4つのitに散らばった検証を見比べて、「非同期処理の検証が無い」と気づく必要があります。しかもcontextごとにこれが繰り返されるので、全体が長くなって余計に見えなくなります。

まとめてあれば、1つのitの中に縦に並びます。何を見ているかが一望できるので、漏れにも気づきやすい。

実行時間が線形に増える

これが一番効いてきます。

itを分けると、その数だけ以下が繰り返されます。

  • beforeが再実行される
  • subjectが再評価される
  • request specならHTTPリクエストがN回飛ぶ
  • トランザクションのbegin/rollbackがN回

5つの検証を5つのitに分けたら、POSTが5回走ります。検証したいのは1回のPOSTの結果なのに、同じ操作を5回やって、毎回1つずつ見ていることになります。

let_it_beはデータ準備の重複を解決しますが、これは解決しません。subjectは1つのitの中では1回しか評価されません(メモ化)が、itをまたぐと再評価されます。つまり操作の実行は各itで必ず走りますlet_it_beを入れたのに思ったほど速くならない、という時はここが原因のことがあります。

1回の差が、何千回分になる

1ケースで100ms増えても、書いている時は誤差です。だから気になりません。

ただ、そのspecはこれから何千回、何万回と実行されます。CIで毎日何十回、自分と他のメンバーのローカルで何百回。1000ケースあれば100秒の差になり、それが全員の待ち時間に乗ります。

書いた時の1回ではなく、これから積み上がる総量で効いてきます。1人が書いた遅いspecのコストを、チーム全員が払い続けることになる。

そして後から直せない

書いた時点では誰も困りません。ケースが積み上がって、CIが10分を超えたあたりで顕在化します。

体感としては、5分くらいが理想です。10分を超えると、流して待たなくなります。別の作業に切り替えて、戻ってくる頃には文脈が切れている。開発フローが変わる境界だと思っています。

ローカルで1時間以上かかる現場を見たことがあります。そうなると並列化でお金を払って解決することになりますが、これは原因を見えなくします。CIが緑で10分なら誰も問題視しませんが、その裏でローカル1時間が固定されます。ローカルで回せなくなると部分実行に頼るようになり、遅いテストが増えていることにも気づけなくなる。

しかも並列化には上限があります。CI側のコンテナ数はコストと契約で決まりますし、parallel_testsのようにマシン内でプロセスを分ける方法も、CPUコア数とメモリで頭打ちになります。

そして直せません。修正コストが全specに及ぶことと、直している間も開発が進んで遅いテストが増え続けることが重なります。追いつかない。

だから早い段階で決めておく必要があります。 後から変えられない類の規約です。

それでも分ける場合

同じcontextの中なら、subjectは同じものを返すのでまとめられます。あえて分ける理由は通常ありません。

分かれるのは、shared_examplesと1対1にならない時です。

検証の軸が2つあって、それぞれ独立に値を取る場合を考えます。

レスポンス: 200 / 403
DBの変更: あり / なし

これが常にペアで動くなら(200なら変更あり、403なら変更なし)、1つのshared_examplesにまとめられます。

でも「200だけど変更なし」のような組み合わせがあると、まとめられません。レスポンスの検証とDBの検証を別のshared_examplesにして、contextごとに組み合わせを選ぶことになります。

context '...' do
  it_behaves_like '正常なレスポンス'
  it_behaves_like '変更されない'
end
context '...' do
  it_behaves_like '正常なレスポンス'
  it_behaves_like '変更される'
end

この時、itはshared_examplesごとに分かれます。まとめたくても共通化の単位が違うのでまとめられない。

分ける理由が可読性ではなく、再利用の単位から来ているのがポイントです。

aggregate_failuresの使い所

aggregate_failuresは、ブロック内のexpectが途中で落ちても最後まで実行して、失敗を全部まとめて報告してくれます。

aggregate_failures do
  expect(a).to eq(1)
  expect(b).to eq(2)
  expect(c).to eq(3)
end

これがあれば、itを分けなくても「どれが落ちたか」が分かります。冒頭に書いた「1 expectation per it」の動機は、これで満たせます。

冒頭に書いた通り、これはまとめる側のために作られた機能です。分割派にとっては「分けなくても失敗が全部見える」という答えになります。

前提は外、独立した検証は中

大事なのは、依存関係のある検証には使わないことです。

it '作成できる' do
  is_expected.to eq(200)        # 前提
  aggregate_failures do         # 独立した検証
    expect(response_parsed_body_sym).to eq(...)
    expect(current_space).to have_attributes(...)
  end
end

200が返らないのにDBを検証しても意味がありません。だから200の検証はaggregate_failuresの外に置いて、そこで止まるようにします。

中に入れてしまうと、200が返らなかった時にDBの検証も走って落ちます。原因1つに対して赤が複数出るitを分けた時と同じ問題が、aggregate_failuresの中で再発します。

model specも同じです。

expect(model).to be_invalid
expect(model.errors.messages).to eq(...)

invalidであることが前提です。validになった瞬間にerrorsは空になるので、集約しても「errorsが空」という無意味な赤が1件増えるだけ。ここは依存があるので、そもそも集約しません。

「使わなくてもいい」より、誤用すると実害がある方が重要だと思っています。

メタデータで指定する書き方

メタデータで指定すると、itの中全体が対象になります。

it '状態が取得できる', :aggregate_failures do
  expect(model.active?).to be(true)
  expect(model.expired?).to be(false)
  expect(model.editable?).to be(true)
end

ブロックで囲まない分すっきりしますが、前提となる検証があると一緒に集約されてしまいます。

使えるのは、itの中の検証が全部独立している場合です。model specで判定メソッドを並べて見るような時。request specはステータスが前提になるので、ブロックで囲む形になります。

実際には無くても回る

正直に書くと、自分はaggregate_failuresを積極的には使っていません。

テストが定着した後は、改修時にしか落ちません。その時に複数の失敗が一度に見えても、結局は順に潰していくことになります。

落ちる回数が多いのは実装中ですが、その試行錯誤は今はAIがやっています。実装中の利便のために構造を決めるのは、順序が逆だと思っています。

分割派への答えにはなりますが、自分にとっては必須ではない、くらいの位置づけです。

まとめ

  • 「1 expectation per it」はaggregate_failuresがなかった時代の対処。それはまとめる側のために作られた
  • 分けると原因1つに対して赤がN個出る。心理的な負担が大きい
  • 説明文が検証内容の写しになり、実装とズレても気づけない
  • 検証項目が散らばって、レビューで漏れが見えない
  • 実行時間が線形に増える。let_it_beでは解決しない
  • 1回の差は誤差でも、これから何千回も実行される。チーム全員の待ち時間に乗る
  • 遅くなってからでは直せない。早い段階で決めておく
  • 分かれるのは、shared_examplesと1対1にならない時。再利用の単位から来る
  • aggregate_failuresは前提を外、独立した検証を中に。依存があるものに使うと同じ問題が再発する
  • メタデータ指定はit全体が対象になるので、前提がない場合だけ使える

次回は、検証の中身を書きます。通っているのに守れていない、というケースの話です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です